type Action = 'undo' | 'redo'

interface Command {
  undo: () => void
  redo: () => void
  groupId?: string
}

type CallbackFunc = (action: string) => void

function removeFromTo(array: Command[], from: number, to: number) {
  array.splice(
    from,
    !to ||
    1 +
    to -
    from +
    (!((to < 0) ^ (from >= 0)) && (to < 0 || -1) * array.length),
  );
  return array.length;
}

export default class UndoManager {
  private commands: Command[] = []
  private index = -1
  private limit = 0
  private isExecuting = false
  private callback?: CallbackFunc

  /**
   * Executes a single command.
   */
  private _execute(command: Command, action: Action) {
    if (!command || typeof command[action] !== 'function') {
      return this;
    }
    this.isExecuting = true;

    command[action]();

    this.isExecuting = false;
    return this;
  }

  /**
   * Adds a command to the queue.
   * @property command           - Command
   */
  add(command: Command) {
    if (this.isExecuting) {
      return this;
    }
    // if we are here after having called undo,
    // invalidate items higher on the stack
    this.commands.splice(this.index + 1, this.commands.length - this.index);
    this.commands.push(command);

    // if limit is set, remove items from the start
    if (this.limit && this.commands.length > this.limit) {
      removeFromTo(this.commands, 0, -(this.limit + 1));
    }

    // set the current index to the end
    this.index = this.commands.length - 1;
    if (this.callback) {
      this.callback(command.type, 'add');
    }
    return this;
  }

  /**
   * Pass a function to be called on undo and redo actions.
   * @property {function} callbackFunc - Callback function
   */
  setCallback(callbackFunc: CallbackFunc) {
    this.callback = callbackFunc;
  }

  /**
   * Performs undo: call the undo function at the current index and decrease the index by 1.
   */
  undo() {
    let command = this.commands[this.index];
    if (!command) {
      return this;
    }

    const groupId = command.groupId;
    while (command.groupId === groupId) {
      this._execute(command, 'undo');
      this.index -= 1;
      command = this.commands[this.index];
      if (!command || !command.groupId) break;
    }

    if (this.callback) {
      this.callback('undo');
    }
    return this;
  }

  /**
   * Performs redo: call the redo function at the next index and increase the index by 1.
   */
  redo() {
    let command = this.commands[this.index + 1];
    if (!command) {
      return this;
    }

    const groupId = command.groupId;
    while (command.groupId === groupId) {
      this._execute(command, 'redo');
      this.index += 1;
      command = this.commands[this.index + 1];
      if (!command || !command.groupId) break;
    }

    if (this.callback) {
      this.callback('redo');
    }
    return this;
  }

  /**
   * Clears the memory, losing all stored states. Resets the index.
   */
  clear() {
    let prev_size = this.commands.length;

    this.commands = [];
    this.index = -1;

    if (this.callback && prev_size > 0) {
      this.callback('clear');
    }
  }

  /**
   * Tests if any undo actions exist.
   * @returns {boolean}
   */
  hasUndo() {
    return this.index !== -1;
  }

  /**
   * Tests if any redo actions exist.
   * @returns {boolean}
   */
  hasRedo() {
    return this.index < this.commands.length - 1;
  }

  /**
   * Returns the list of queued commands.
   * @param {string} [groupId] - Optionally filter commands by group ID
   * @returns {array}
   */
  getCommands(groupId: string) {
    return groupId ? this.commands.filter(c => c.groupId === groupId) : this.commands;
  }

  /**
   * Returns the index of the actions list.
   * @returns {number}
   */
  getIndex() {
    return this.index;
  }

  /**
   * Sets the maximum number of undo steps. Default: 0 (unlimited).
   * @property {number} max - Maximum number of undo steps
   */
  setLimit(max: number) {
    this.limit = max;
  }
}
